Skip to content

fix(hooks): guard-main-checkout reads the path key its routed tool carries - #15665

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-11810-guard-hook-notebook-path
Sep 5, 2026
Merged

fix(hooks): guard-main-checkout reads the path key its routed tool carries#15665
os-zhuang merged 1 commit into
mainfrom
claude/issue-11810-guard-hook-notebook-path

Conversation

@claude

@claude claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Fixes #11810

What was wrong

.claude/settings.json routes Edit|Write|NotebookEdit at this hook, and the hook extracted
exactly one key, .tool_input.file_path. NotebookEdit does not carry that key — its path
key is notebook_path. Verified against the tool's own documented input schema rather than
assumed: notebook_path (absolute, required) and new_source (required), plus cell_id,
cell_type, edit_mode; there is no file_path anywhere in a NotebookEdit payload.

So every notebook edit produced an empty path, fell into the no-path branch and was judged by
CLAUDE_PROJECT_DIR instead of by the file being written. The jq-less fallback grepped for
the same single key, so it missed it too. The resulting verdict is a constant per session,
wrong in both directions depending only on where the session happens to be rooted: notebook
edits inside a correctly created worktree were refused, and notebook edits into the shared
primary checkout were waved through
.

The fix

The hook now carries one tool-to-path-key table and reads the key the routed tool actually
carries, in the jq path and in the text-scan fallback alike:

known_path_keys='Edit=file_path Write=file_path MultiEdit=file_path NotebookEdit=notebook_path'
  • A tool the table names is read for its own key.
  • A tool the table does not name is not routed here by the matcher, so it keeps today's
    permissive read: any known key it happens to carry, else the project-dir fallback.
  • A tool the table does name that carries no path under its own key is schema drift,
    not a missing path. It blocks (exit 2) with a message naming the tool and the key it wanted,
    instead of silently falling back to a verdict about the session. That was the seat's ruling
    on the frame's contract-tightening axis: tolerance here is exactly what hides the error.

The matcher and the table are now a checked relation

This was triage's acceptance condition — "a tool listed in the matcher" and "a path key the
extractor knows" must be a checkable relation, not one more //. The self-test's wiring
section reads known_path_keys out of the hook, splits the matcher on |, and asserts every
routed tool has a row:

  ok   pair   Edit          -> .tool_input.file_path
  ok   pair   Write         -> .tool_input.file_path
  ok   pair   NotebookEdit  -> .tool_input.notebook_path
  note       MultiEdit has a row in known_path_keys; the matcher does not route it

A row with no matcher entry is the harmless direction and prints a note. A tool routed here
with no row is a failure — so the next tool routed at this hook cannot repeat this defect
silently, which was the whole point of not just adding one more key.

Before to after: the notebook rows

The known-hole section flips to the intended verdicts and its banner is gone. PROJ is
CLAUDE_PROJECT_DIR; every row points it somewhere that would give the wrong answer under a
session-based verdict, so a passing row can only have come from the notebook's own path.

payload PROJ before after correct
NotebookEdit into $MAIN $MAIN block block block (was right answer, wrong reason)
NotebookEdit into $WT $MAIN block allow allow
NotebookEdit into $PLAIN $MAIN block allow allow
NotebookEdit into $MAIN $WT allow block block
NotebookEdit into $WT $WT (not pinned) allow allow
NotebookEdit into $MAIN $PLAIN allow block block
NotebookEdit into $WT $PLAIN (new) allow allow
Edit control, same three files wrong dir each allow/block per path unchanged the two payload shapes now agree
NotebookEdit, new file in a new dir under $MAIN / $WT $PLAIN (new) block / allow the ancestor walk reaches notebooks too

Rows that were pinned as INTENDED and that this change flips

Per the ruling: a row pinned as intended behaviour (not under a known-hole banner) that
contradicts the drift rule gets flipped, listed, and explained. Three places:

  1. tool_name=NotebookEdit into $WT was allow, now block. That row fed a
    file_path-carrying payload under the name NotebookEdit. Under the fix that is a routed
    tool with no path under its own key, i.e. drift. The section's premise — "tool_name is
    never consulted" — is itself falsified by the fix (tool_name now selects the key, though
    the verdict still comes from the path alone), so the section was rewritten rather than
    patched, and it now covers Edit/Write/MultiEdit by file_path, NotebookEdit by
    notebook_path, and an unrouted tool through both keys.
  2. no usable path, CLAUDE_PROJECT_DIR=$WT and =$PLAIN for the probe
    {"tool_name":"Edit","tool_input":{}} were allow, now block.
    Routed tool, no path
    under its key: drift, so the session's root no longer decides.
  3. The CLAUDE_PROJECT_DIR unset section's probe was re-pointed, not flipped. It exists
    to measure the cwd fallback, and that branch still exists for unrouted payloads, so its
    probe is now an unrouted AnythingElse and its three verdicts are unchanged. The routed
    Edit probe moved into the new drift section, where it is asserted as block under all
    three roots.

The remaining known hole in the matrix — the */worktrees/* substring predicate — is
untouched, banner and all.

Self-test counts

before after
objectstack guard-main-checkout.selftest.sh 87 passed, 0 failed 116 passed, 0 failed
objectui guard-main-checkout.selftest.sh 87 passed, 0 failed 116 passed, 0 failed

Run against the old matrix, the new hook produces exactly 9 failures — the 9 rows listed
above and nothing else, in both repos. No unlisted row changed.

The jq-less fallback is exercised, not assumed: the matrix runs the notebook rows, the drift
row and the decoy rows a second time with jq removed from PATH (the existing nojq
fixture), because a fallback that still reads one key sends notebook edits straight back to
the no-path branch whenever jq is away.

Non-vacuity

Each new class was driven red against a mutated copy of the hook (GUARD_MAIN_CHECKOUT_HOOK
points the matrix at it, so the tree is never mutated and there is no restore leg to get
wrong). Each mutation was proved on disk by grepping for the injected and the removed text
before the matrix was run, and the hook blob in the worktree was checked equal to its HEAD
blob. Identical results in both repos:

mutation result
point NotebookEdit's row at file_path 107 passed, 9 failed
delete NotebookEdit's row from known_path_keys 111 passed, 5 failed — including FAIL NotebookEdit is routed to this hook but has no row in known_path_keys, which is the pairing check doing its job
neutralise the drift arm so a routed tool falls back to the project dir 107 passed, 9 failed
control: unmutated hook 116 passed, 0 failed

Cross-repo convergence

The two hooks' executable lines are byte-identical before and after. Diff of the two files
after this change, comments stripped: empty. Full diff, comments included:

106,113d105
< # (the 8-line comment block about the worktree recipe that the objectstack copy already carried)

That is the same eight comment lines the objectstack copy carried before this change, and no
executable line differs. The self-tests differ only where they already did: objectui's porting
header, and the remaining known hole's issue reference pointing at each repo's own card.

Exclusions

  • .claude/settings.json — untouched (the matcher is not changed).
  • the */worktrees/* predicate and its known-hole section — untouched.
  • guard-main-checkout-bash.sh — untouched; it parses .tool_input.command and never sees
    notebook payloads.

Gates

Family derived from the change set by the repo's own tool, never from a hand-written list:
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at
5f871cbfd — 2 paths vs merge base 7087f99c1, 13 commands. Exit codes captured by
redirecting first, never across a pipe.

gate exit verdict line
node scripts/check-closing-keyword-parity.mjs 0 clean
node scripts/check-closing-keyword-parity.mjs --self-test 0 24 assertions, 5 mutations of the shipped parsers each driven to red.
node scripts/check-comment-mask-corpus.mjs 0 5968 files, 0 disagree, 0 unparseable
pnpm --filter @objectstack/lint run check:doc-formula-expressions 0 green after building @objectstack/formula and @objectstack/lint (it exits 3 PREREQUISITE NOT MET without them)
pnpm check:agent-test-spelling 0 0 violations — 450 file(s)
pnpm check:bash32-floor 0 27 tracked shell file(s) under scripts/**, .claude/hooks/**, .githooks/** name no bash 4+ construct... — the gate that actually covers this surface
pnpm check:doc-authoring 0 394 files clean / 46 published skill files clean
pnpm check:driver-memory-census 0 clean
pnpm check:nul-bytes 0 --self-test: 75 assertions over a temp git repo
pnpm check:pm-governed-merges 0 --self-test: 263 assertions
pnpm check:refd-timer-probe 0 11 cases pass, negative controls included.
pnpm check:skill-frame-sync 0 2 copies of the decision frame are structurally isomorphic across 2 files
pnpm check:watch-hint-literal 0 58 declaration(s) across 4 rostered name(s)

node scripts/pm/dispatch-gates.mjs --ran closes the loop:

✓ dispatch-gates --ran: 13 derived famil(ies) accounted for — 13 run, 0 NOT-MEASURED.

Derivation staleness, recorded rather than hidden: the tool reports this tree as behind
origin/main (now 791a0cbe6), with one gate source changed in that range,
scripts/pm/check-skill-line-ratchet.mjs. Its population is a roster of skills/** and
.claude/skills/** files; this change set is .claude/hooks/**, so it is not a lead here.
Re-deriving after a fetch produced a byte-identical 13-command list.

pnpm lint — NOT MEASURED, by eslint's own configuration, not by a choice of mine.
pnpm lint is eslint . --no-inline-config. Asked directly about the two changed files it
answers, per file:

File ignored because no matching configuration was supplied.

Both changed files are shell, outside eslint's configured surface entirely, so this diff
cannot move any verdict about a file eslint does lint. The shell surface is covered instead
by check:bash32-floor above, which names .claude/hooks/** among its scan roots and is
green over 27 files.

Label: skip-changeset, applied at open — this PR publishes nothing from any package.

Generated by Claude Code


Generated by Claude Code

guard-main-checkout.sh extracted exactly one key, .tool_input.file_path, while
settings.json routes Edit, Write AND NotebookEdit at it. NotebookEdit carries its
path in notebook_path, so every notebook edit yielded an empty path, took the
no-path branch and was judged by CLAUDE_PROJECT_DIR instead of by the file. That
verdict is a constant per session and wrong in both directions: notebook edits
inside a correctly-created worktree were refused when the session was rooted in the
shared checkout, and notebook edits INTO the shared checkout were waved through when
it was not. The jq-less fallback grepped the same single key and missed it too.

The hook now carries a tool-to-path-key table and reads the key the routed tool
actually carries, in the jq path and in the text-scan fallback alike. A tool the
table does not name keeps the permissive read: any known key it carries, else the
project dir. A tool the table DOES name that carries no path under its own key is
schema drift, not a missing path, and blocks with a message naming the tool and the
key rather than falling back to a verdict about the session.

The matcher and the table are now a checked relation, which was the acceptance
condition: the self-test reads known_path_keys out of the hook and reds if
settings.json routes a tool that has no row in it, so the next tool routed here
cannot repeat this defect silently. A row with no matcher entry prints a note.

The matrix's known-hole section for this defect flips to the intended verdicts and
its banner goes; the unrelated known hole below it is untouched, as are
settings.json, the */worktrees/* predicate and guard-main-checkout-bash.sh.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox

os-steve commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

ACCEPT — governed in-seat review of PR #15665, head 5f871cbfd (skills seat, 2026-09-05T02:4xZ), the objectstack half of the cross-repo flight with objectui PR #7686 (accepted in the same act).

Implemented-by: claude/issue-11810-guard-hook-notebook-path (os-dev subagent, one dev for both repos)
Reviewed-by: session_019RfFHiRCSs3JXLK4cwcfox (skills seat, cross-lane takeover of #11810)

Verified in a detached compare worktree at the PR head against merge-base 7087f99c1, not from the report: two files, .claude/hooks/guard-main-checkout.sh and its self-test. The hook now carries one tool-to-path-key table (known_path_keys), reads the key the routed tool actually carries in the jq path and in the jq-less text scan alike, and blocks with a message naming the tool and the missing key when a routed tool's payload carries no path under its own key (the seat's ruling on schema drift); a tool the table does not name keeps today's permissive read. The self-test reads the table out of the hook and reds when settings.json routes a tool without a row — the acceptance condition triage set, met mechanically. */worktrees/* (#11809), settings.json and the bash guard are untouched; the KNOWN HOLE #11809 section stands as it was. Seat runs on the head: self-test 116 passed, 0 failed (87 on main); the comments-stripped diff between the two repos' hooks is empty (the objectstack copy keeps its eight extra comment lines); no model names, no new card numbers in the hook. The dev's three throwaway-copy mutations (wrong key, deleted row, neutralised drift arm) each turned the matrix red where expected and the control stayed 116/116. CI on 5f871cbfd: 29 check runs, 17 success, 12 skipped, none failing; check:bash32-floor green over the 27 tracked shell files.

Three matrix rows that were pinned as intended behaviour contradicted the drift ruling and were flipped; each is listed and explained in the PR body — accepted as the ruling's consequence, not as scope creep.

Landing regime: governed (.claude/**) ⇒ the PR stays a draft; reviewers os-zhuang + hotlong requested in this same act; human merge. Closes #11810 (the devx lane's card, taken over under the cross-lane rule; receipt left there).


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review September 5, 2026 03:22
@os-zhuang
os-zhuang enabled auto-merge September 5, 2026 03:22
@os-zhuang
os-zhuang added this pull request to the merge queue Sep 5, 2026
Merged via the queue into main with commit a5f29af Sep 5, 2026
31 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-11810-guard-hook-notebook-path branch September 5, 2026 04:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

3 participants